今天會介紹如何基於消費者驅動合約建立 Next.js 後端 API的測試,測試範圍包含步驟 3 到步驟 5。
程式碼請參考 D22/consumer-driven-contract
回顧一下我們昨天的合約
存在使用者
richard_00
到richard_99
的狀態下
- 前端送出
GET /api/v1/users/richard_01
請求,會收到狀態碼 200 加上格式正確的使用者物件- 前端送出
GET /api/v1/users/richard_x
請求,會收到狀態碼 404在後端發生錯誤的情況下
- 前端送出
GET /api/v1/users/richard_01
請求,會收到狀態碼 500
根據 AAA 原則,第一步 Arange 要做的是製造 存在使用者 richard_00
到 richard_99
的狀態
製造這 100 個 richard 很簡單,我們可以這樣做
const richard00To99: UserModel[] = pipe(
RA.replicate(100)(''),
RA.map((_, i) => `0${i}`.slice(-2)),
RA.map((index) => ({
name: `richard_${index}`,
role: 'Administrator',
}))
)
如果要測的更狠一點,也可以用前面講過的 Schema ,它有 Arbitraries 功能可以幫助我們生成隨機測試資料。
import { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
import * as A from "@effect/schema/Arbitrary";
import * as fc from "fast-check";
const Person = S.struct({
name: S.string,
age: S.string.pipe(S.numberFromString, S.int())
});
// Arbitrary for the To type
const PersonArbitraryTo = A.to(Person)(fc);
console.log(fc.sample(PersonArbitraryTo, 2));
/*
[
{ name: 'WJh;`Jz', age: 3.4028216409684243e+38 },
{ name: 'x&~', age: 139480325657985020 }
]
*/
// Arbitrary for the From type
const PersonArbitraryFrom = A.from(Person)(fc);
console.log(fc.sample(PersonArbitraryFrom, 2));
/*
[ { name: 'Q}"H@aT', age: ']P$8w' }, { name: '|', age: '"' } ]
*/
顧名思義 Testcontainers
就是一個測試專用的、用過即丟的 container,使用它可以確保測試過程不會因為操作資料庫而影響到其他程式,或是影響測試結果。
接下來請參考以下步驟準備 testcontainer
npm i -D testcontainers-mongoose
// src\app\api\v1\users\[username]\route.spec.ts
let mongoTestContainer: StartedMongoTestContainer
beforeAll(async () => {
mongoTestContainer = await startedMongoTestContainerOf('mongo:7.0.0')
const mongoUri = mongoTestContainer.getUri()
// mongoUri 等等會使用到
})
afterEach(async () => {
await mongoTestContainer.clearDatabase()
})
afterEach(async () => {
await mongoTestContainer.clearDatabase()
})
Next.js 環境和一般專注於做後端服務的框架不同,直接使用會造成重複連線問題,所以我們需要客製化一個 mongoose singleton。
//src\plugins\mongoose-ex\connect.ts
import mongoose from 'mongoose'
declare global {
var mongoose: any // This must be a `var` and not a `let / const`
}
let cached = global.mongoose
if (!cached) {
cached = global.mongoose = { conn: null, promise: null }
}
const connect = async (uri: string) => {
if (cached.conn) {
return cached.conn
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
}
cached.promise = mongoose.connect(uri, opts).then((mongoose) => {
return mongoose
})
}
try {
cached.conn = await cached.promise
} catch (e) {
cached.promise = null
throw e
}
return cached.conn
}
export default connect
準備好 singleton 以後,我們就可以搶先在其他測試開始執行前,先把 mongoose 連線建立好,並且把先前準備好的測試資料 richard00To99
塞進去資料庫。
// src\app\api\v1\users\[username]\route.spec.ts
let mongoTestContainer: StartedMongoTestContainer
beforeAll(async () => {
mongoTestContainer = await startedMongoTestContainerOf('mongo:7.0.0')
const mongoUri = mongoTestContainer.getUri()
await MongooseEx.connect(mongoUri)
await UserModel.insertMany(richard00To99)
})
開始寫測試前,我們先了解一下要測的目標。
//src\app\api\v1\users\[username]\route.ts
interface Route {
params: { username: string }
}
export const GET = async (request: Request, route: Route): Promise<Response> => {
throw Error('Todo')
}
它和 page.tsx 類似,可以從資料夾取得路徑參數。以上面例子來說, params: { username: string }
裡面的 username
必定會對應到請求路徑中 GET /api/v1/users/[username]
裡面的 [username]
。
除此之外比較特別的是它使用了瀏覽器 fetch API 中的 Request 和 Response 型別 ,但是我們實際執行環境卻是 Node.js,因此會導致 route handler
在單元測試環境出現問題 !
/**
* @vitest-environment edge-runtime
*/
為了解決問題我們必須在測試檔案加上魔法字串,指定運行環境是 edge-runtime
來避免編譯錯誤
再 ... 再 Arrange 一點針對這個測試的測資,這邊會參考到 Request API
describe('when exists user richard_00 to richard_99', () => {
it('should reply a user when username is richard_01', async () => {
//arrange
const username = 'richard_01'
const request = new Request(`http://localhost/api/v1/users/${username}`)
const params = { params: { username } }
})
})
Act,把參數塞進去 route handler,完整模擬請求打進來時它被呼叫的樣子。
describe('when exists user richard_00 to richard_99', () => {
it('should reply a user when username is richard_01', async () => {
//arrange
...
//act
const response = await GET(request, params)
})
})
Assert,這邊會用到 Response API,並且使用 @effect/schema
的 parseSync
,來直接把資料解開驗證
describe('when exists user richard_00 to richard_99', () => {
it('should reply a user when username is richard_01', async () => {
//arrange
...
//act
...
//assert
const status = response.status
const rawData = await response.json()
const data = S.parseSync(User.schema)(rawData)
expect(status).toBe(200)
expect(data.name).toBe(username)
})
})
parseSync
如果出錯會直接拋出清楚的告訴你錯在哪的錯誤訊息。
以此類推,相信後面兩個測資大家也都會做了吧 (?
GET /api/v1/users/richard_x
請求,會收到狀態碼 404GET /api/v1/users/richard_01
請求,會收到狀態碼 500明天就來完成最後一步,實作 Next.js page router 並通過測試。